SPB Git forge

spb/spinza

Public
8commits 1branches 0releases
1.6 MBsize
maindefault branch
16 days agolast push
TypeScript 97.6% SQL 1.4% JavaScript 0.5%
17.9 KB · 350 lines tsx
Raw Blame History
1"use client";23import * as React from "react";4import Link from "next/link";5import { useParams } from "next/navigation";6import { ArrowLeft, Ban, CheckCircle2, Scale, UserCheck, XCircle } from "lucide-react";7import { Button, Tabs } from "@/components/ui";8import { api } from "@/lib/api";9import { toast } from "@/lib/store";10import { timeAgo } from "@/lib/utils";11import { useAdminQuery } from "@/components/admin/use-query";12import type { LedgerRow, RoundRow, SecurityEventRow, SessionRow, UserDetailResponse } from "@/components/admin/types";13import { PageHeader, RefreshButton, Panel, DataTable, ErrorState, TableSkeleton, KV, Pill, UserStatusPill, SeverityPill, ConfirmDialog, DenseInput, DenseTextarea, FieldLabel, InlineError, JsonToggle, Mono, StatTile, StatGrid, type Column } from "@/components/admin/primitives";14import { dateTime, describeError, int, multiplier, sc, signedSC } from "@/components/admin/format";15import { cn } from "@/lib/utils";1617type Tab = "ledger" | "rounds" | "sessions" | "events";1819const ledgerCols: Column<LedgerRow>[] = [20  { key: "at", header: "When", render: (r) => <span className="text-fg-3">{dateTime(r.createdAt)}</span> },21  { key: "type", header: "Type", render: (r) => <Pill tone={r.type === "ADMIN_ADJUSTMENT" ? "warn" : r.type === "BET" ? "muted" : r.type === "WIN" ? "accent" : "neutral"}>{r.type}</Pill> },22  { key: "amount", header: "Amount", align: "right", render: (r) => <span className={cn(r.amount > 0 ? "text-success" : r.amount < 0 ? "text-fg-2" : "text-fg-3")}>{signedSC(r.amount)}</span> },23  { key: "after", header: "Balance after", align: "right", render: (r) => sc(r.balanceAfter) },24  { key: "ref", header: "Reference", mono: true, render: (r) => <Mono>{r.reference ?? "—"}</Mono> },25  { key: "meta", header: "Meta", render: (r) => <JsonToggle value={r.meta} /> },26];2728const roundCols: Column<RoundRow>[] = [29  { key: "at", header: "When", render: (r) => <span className="text-fg-3">{dateTime(r.createdAt)}</span> },30  { key: "game", header: "Game", render: (r) => <Link href={`/admin/games/${r.gameSlug}`} className="hover:text-accent-2">{r.gameSlug}</Link> },31  { key: "bet", header: "Bet", align: "right", render: (r) => sc(r.bet) },32  { key: "win", header: "Win", align: "right", render: (r) => <span className={r.win > 0 ? "text-credit" : "text-fg-3"}>{sc(r.win)}</span> },33  { key: "mult", header: "×", align: "right", render: (r) => multiplier(r.multiplier) },34  { key: "features", header: "Features", render: (r) => (r.features.length ? <span className="text-fg-2">{r.features.join(", ")}</span> : <span className="text-fg-4">—</span>) },35  { key: "flags", header: "", render: (r) => <span className="flex gap-1">{r.freeSpins ? <Pill tone="info">FS</Pill> : null}{r.bonus ? <Pill tone="accent">Bonus</Pill> : null}{r.jackpotTier ? <Pill tone="warn">{r.jackpotTier}</Pill> : null}</span> },36  { key: "after", header: "Balance after", align: "right", render: (r) => sc(r.balanceAfter) },37  { key: "ms", header: "ms", align: "right", render: (r) => r.durationMs ?? "—" },38  { key: "id", header: "Round", mono: true, render: (r) => <Mono>{r.roundId}</Mono> },39];4041const sessionCols: Column<SessionRow>[] = [42  { key: "created", header: "Started", render: (r) => <span className="text-fg-3">{dateTime(r.createdAt)}</span> },43  { key: "seen", header: "Last seen", render: (r) => timeAgo(r.lastSeenAt) },44  { key: "expires", header: "Expires", render: (r) => <span className="text-fg-3">{dateTime(r.expiresAt)}</span> },45  { key: "ip", header: "IP", mono: true, render: (r) => <Mono>{r.ip ?? "—"}</Mono> },46  { key: "ua", header: "User agent", render: (r) => <span className="block max-w-[420px] truncate text-fg-3" title={r.userAgent ?? ""}>{r.userAgent ?? "—"}</span> },47];4849const eventCols: Column<SecurityEventRow>[] = [50  { key: "at", header: "When", render: (r) => <span className="text-fg-3">{dateTime(r.createdAt)}</span> },51  { key: "type", header: "Type", mono: true, render: (r) => <Mono className="text-fg">{r.type}</Mono> },52  { key: "sev", header: "Severity", render: (r) => <SeverityPill severity={r.severity} /> },53  { key: "ip", header: "IP", mono: true, render: (r) => <Mono>{r.ip ?? "—"}</Mono> },54  { key: "meta", header: "Meta", render: (r) => <JsonToggle value={r.meta} /> },55];5657export default function AdminUserDetailPage() {58  const { id } = useParams<{ id: string }>();59  const q = useAdminQuery<UserDetailResponse>(id ? `/api/admin/users/${id}` : null);60  const d = q.data;61  const [tab, setTab] = React.useState<Tab>("ledger");62  const [adjustOpen, setAdjustOpen] = React.useState(false);63  const [statusOpen, setStatusOpen] = React.useState(false);6465  return (66    <>67      <PageHeader68        eyebrow="User"69        title={d ? d.user.username : "User detail"}70        description={d ? <span className="font-mono text-[12px]">{d.user.id}</span> : undefined}71        actions={72          <>73            <Button variant="ghost" size="sm" href="/admin/users">74              <ArrowLeft className="h-3.5 w-3.5" /> All users75            </Button>76            <RefreshButton onClick={() => void q.refresh()} loading={q.refreshing} />77          </>78        }79      />8081      {q.error && !d ? (82        <ErrorState error={q.error} onRetry={() => void q.refresh()} title={q.error.status === 404 ? "User not found" : undefined} />83      ) : !d ? (84        <div className="grid gap-4 lg:grid-cols-3">85          <Panel>86            <TableSkeleton rows={4} cols={2} />87          </Panel>88          <Panel>89            <TableSkeleton rows={4} cols={2} />90          </Panel>91          <Panel>92            <TableSkeleton rows={4} cols={2} />93          </Panel>94        </div>95      ) : (96        <div className={cn("space-y-4", q.stale && "opacity-70")}>97          <div className="grid gap-4 lg:grid-cols-3">98            {/* Profile */}99            <Panel title="Profile" actions={<UserStatusPill status={d.user.status} />}>100              <KV101                items={[102                  { label: "Username", value: d.user.username },103                  { label: "Level", value: `${d.user.level} · ${int(d.user.xp)} XP` },104                  { label: "Created", value: dateTime(d.user.createdAt) },105                  { label: "Last login", value: d.user.lastLoginAt ? `${timeAgo(d.user.lastLoginAt)} · ${dateTime(d.user.lastLoginAt)}` : "never" },106                  { label: "Age confirmed", value: d.user.ageConfirmedAt ? dateTime(d.user.ageConfirmedAt) : "—" },107                  { label: "Last rescue", value: d.user.lastRescueAt ? dateTime(d.user.lastRescueAt) : "—" },108                  { label: "Total spins", value: int(d.user.totalSpins) },109                  { label: "Games played", value: int(d.user.gamesPlayed) },110                  { label: "Biggest win", value: sc(d.user.biggestWin) },111                  { label: "Biggest multiplier", value: multiplier(d.user.biggestMultiplier) },112                ]}113              />114              <div className="mt-4 flex flex-wrap gap-2 border-t border-line pt-4">115                {d.user.status === "suspended" ? (116                  <Button variant="outline" size="sm" onClick={() => setStatusOpen(true)}>117                    <UserCheck className="h-3.5 w-3.5" /> Reactivate account118                  </Button>119                ) : (120                  <Button variant="danger" size="sm" onClick={() => setStatusOpen(true)} disabled={d.user.status === "deleted"}>121                    <Ban className="h-3.5 w-3.5" /> Suspend account122                  </Button>123                )}124              </div>125            </Panel>126127            {/* Wallet */}128            <Panel129              title="Wallet"130              actions={131                d.invariant.ok ? (132                  <Pill tone="success">133                    <CheckCircle2 className="h-3 w-3" /> ledger = balance134                  </Pill>135                ) : (136                  <Pill tone="danger">137                    <XCircle className="h-3 w-3" /> ledger ≠ balance138                  </Pill>139                )140              }141            >142              <div className="text-[11px] font-medium uppercase tracking-wider text-fg-4">Balance</div>143              <div className="text-3xl font-semibold tracking-tight text-credit">{sc(d.wallet?.balance ?? 0)}</div>144              <KV145                className="mt-4"146                items={[147                  { label: "Lifetime granted", value: sc(d.wallet?.lifetimeGranted ?? 0) },148                  { label: "Lifetime wagered", value: sc(d.wallet?.lifetimeWagered ?? 0) },149                  { label: "Lifetime won", value: sc(d.wallet?.lifetimeWon ?? 0) },150                  { label: "Updated", value: d.wallet ? timeAgo(d.wallet.updatedAt) : "—" },151                ]}152              />153              <div className={cn("mt-4 rounded-sm border px-3 py-2 text-[12px]", d.invariant.ok ? "border-success/30 bg-success/5 text-fg-2" : "border-danger/40 bg-danger/10 text-danger")}>154                <div className="flex items-center gap-1.5 font-semibold uppercase tracking-wider">155                  <Scale className="h-3 w-3" /> Ledger invariant156                </div>157                <div className="mt-1 flex justify-between tabular">158                  <span>Σ ledger amounts</span>159                  <span>{sc(d.invariant.ledgerTotal)}</span>160                </div>161                <div className="flex justify-between tabular">162                  <span>Wallet balance</span>163                  <span>{sc(d.invariant.balance)}</span>164                </div>165                {!d.invariant.ok ? <div className="mt-1 font-medium">Mismatch of {signedSC(d.invariant.balance - d.invariant.ledgerTotal)} — investigate before adjusting.</div> : null}166              </div>167              <div className="mt-4 border-t border-line pt-4">168                <Button variant="outline" size="sm" onClick={() => setAdjustOpen(true)}>169                  Adjust wallet170                </Button>171              </div>172            </Panel>173174            {/* Activity snapshot */}175            <div className="grid content-start gap-3">176              <StatGrid cols={3} className="grid-cols-2">177                <StatTile label="Ledger rows" value={int(d.ledger.length)} sub="latest 50" compact />178                <StatTile label="Rounds" value={int(d.rounds.length)} sub="latest 30" compact />179                <StatTile label="Sessions" value={int(d.sessions.length)} sub="active + expired" compact />180              </StatGrid>181              <StatGrid cols={3} className="grid-cols-2">182                <StatTile label="Security events" value={int(d.events.length)} tone={d.events.some((e) => e.severity === "high") ? "danger" : "neutral"} sub="latest 30" compact />183                <StatTile label="Round RTP" value={roundRtp(d.rounds)} sub="latest rounds" compact />184                <StatTile label="Bonus rounds" value={int(d.rounds.filter((r) => r.bonus || r.freeSpins).length)} compact />185              </StatGrid>186            </div>187          </div>188189          <Panel190            padded={false}191            title={192              <Tabs<Tab>193                value={tab}194                onChange={setTab}195                items={[196                  { value: "ledger", label: `Ledger (${d.ledger.length})` },197                  { value: "rounds", label: `Rounds (${d.rounds.length})` },198                  { value: "sessions", label: `Sessions (${d.sessions.length})` },199                  { value: "events", label: `Security (${d.events.length})` },200                ]}201              />202            }203          >204            {tab === "ledger" ? <DataTable columns={ledgerCols} rows={d.ledger} rowKey={(r) => r.id} dense empty="No ledger entries." /> : null}205            {tab === "rounds" ? <DataTable columns={roundCols} rows={d.rounds} rowKey={(r) => r.id} dense empty="No rounds played." /> : null}206            {tab === "sessions" ? <DataTable columns={sessionCols} rows={d.sessions} rowKey={(r) => r.id} dense empty="No sessions." /> : null}207            {tab === "events" ? <DataTable columns={eventCols} rows={d.events} rowKey={(r) => r.id} dense empty="No security events for this user." /> : null}208          </Panel>209210          <AdjustDialog open={adjustOpen} onClose={() => setAdjustOpen(false)} userId={d.user.id} username={d.user.username} balance={d.wallet?.balance ?? 0} onDone={() => void q.refresh()} />211          <StatusDialog open={statusOpen} onClose={() => setStatusOpen(false)} userId={d.user.id} username={d.user.username} suspended={d.user.status === "suspended"} onDone={() => void q.refresh()} />212        </div>213      )}214    </>215  );216}217218function roundRtp(rounds: RoundRow[]): string {219  const bet = rounds.reduce((a, r) => a + r.bet, 0);220  const win = rounds.reduce((a, r) => a + r.win, 0);221  return bet ? `${((win / bet) * 100).toFixed(1)}%` : "—";222}223224function AdjustDialog({ open, onClose, userId, username, balance, onDone }: { open: boolean; onClose: () => void; userId: string; username: string; balance: number; onDone: () => void }) {225  const [amount, setAmount] = React.useState("");226  const [note, setNote] = React.useState("");227  const [busy, setBusy] = React.useState(false);228  const [error, setError] = React.useState<string | null>(null);229  const [confirm, setConfirm] = React.useState(false);230231  const n = Number(amount);232  const amountOk = /^-?\d+$/.test(amount.trim()) && n !== 0 && Math.abs(n) <= 1_000_000_000;233  const noteOk = note.trim().length >= 3;234  const after = balance + (amountOk ? n : 0);235236  function reset() {237    setAmount("");238    setNote("");239    setError(null);240    setConfirm(false);241  }242243  async function submit() {244    if (!amountOk || !noteOk) return;245    setBusy(true);246    setError(null);247    try {248      const r = await api<{ balance: number }>(`/api/admin/users/${userId}/adjust`, { json: { amount: n, note: note.trim() } });249      toast({ title: "Wallet adjusted", description: `${username} · ${signedSC(n)} → ${sc(r.balance)}`, tone: "success" });250      reset();251      onClose();252      onDone();253    } catch (e) {254      setError(describeError(e));255      setConfirm(false);256    } finally {257      setBusy(false);258    }259  }260261  return (262    <ConfirmDialog263      open={open}264      onClose={() => {265        if (busy) return;266        reset();267        onClose();268      }}269      onConfirm={() => (confirm ? void submit() : setConfirm(true))}270      title={confirm ? "Confirm wallet adjustment" : `Adjust wallet · ${username}`}271      description={confirm ? undefined : "Signed amount in Spinza Credits (SC). Positive grants, negative removes. Logged as a high-severity security event."}272      confirmLabel={confirm ? `Apply ${signedSC(amountOk ? n : 0)}` : "Review"}273      danger={amountOk && n < 0}274      loading={busy}275      disabled={!amountOk || !noteOk}276    >277      {confirm ? (278        <div className="rounded-sm border border-line bg-bg-1 p-3 text-[13px]">279          <div className="flex justify-between">280            <span className="text-fg-3">Player</span>281            <span className="font-medium">{username}</span>282          </div>283          <div className="mt-1 flex justify-between">284            <span className="text-fg-3">Adjustment</span>285            <span className={cn("font-semibold tabular", n > 0 ? "text-success" : "text-danger")}>{signedSC(n)}</span>286          </div>287          <div className="mt-1 flex justify-between">288            <span className="text-fg-3">Balance</span>289            <span className="tabular">290              {sc(balance)} → <span className="text-credit">{sc(after)}</span>291            </span>292          </div>293          <div className="mt-2 border-t border-line pt-2 text-fg-2">“{note.trim()}”</div>294          {after < 0 ? <InlineError>Resulting balance would be negative.</InlineError> : null}295        </div>296      ) : (297        <div className="space-y-3">298          <div>299            <FieldLabel hint={amountOk ? `→ ${sc(after)}` : "integer, non-zero"}>Amount (SC)</FieldLabel>300            <DenseInput inputMode="numeric" placeholder="e.g. 5000 or -2500" value={amount} onChange={(e) => setAmount(e.target.value.replace(/[^\d-]/g, ""))} className="font-mono" />301          </div>302          <div>303            <FieldLabel hint={`${note.trim().length}/200 · min 3`}>Note</FieldLabel>304            <DenseTextarea rows={2} maxLength={200} placeholder="Why is this adjustment being made?" value={note} onChange={(e) => setNote(e.target.value)} />305          </div>306          <div className="flex gap-1.5">307            {[1000, 5000, 10000, -1000].map((v) => (308              <button key={v} type="button" onClick={() => setAmount(String(v))} className="rounded-xs border border-line px-2 py-1 text-[11px] text-fg-3 hover:text-fg">309                {signedSC(v)}310              </button>311            ))}312          </div>313        </div>314      )}315      {error ? (316        <div className="mt-3">317          <InlineError>{error}</InlineError>318        </div>319      ) : null}320    </ConfirmDialog>321  );322}323324function StatusDialog({ open, onClose, userId, username, suspended, onDone }: { open: boolean; onClose: () => void; userId: string; username: string; suspended: boolean; onDone: () => void }) {325  const [busy, setBusy] = React.useState(false);326  const [error, setError] = React.useState<string | null>(null);327  const next = suspended ? "active" : "suspended";328329  async function submit() {330    setBusy(true);331    setError(null);332    try {333      await api(`/api/admin/users/${userId}/status`, { json: { status: next } });334      toast({ title: next === "suspended" ? "Account suspended" : "Account reactivated", description: username, tone: next === "suspended" ? "danger" : "success" });335      onClose();336      onDone();337    } catch (e) {338      setError(describeError(e));339    } finally {340      setBusy(false);341    }342  }343344  return (345    <ConfirmDialog open={open} onClose={onClose} onConfirm={() => void submit()} title={suspended ? `Reactivate ${username}?` : `Suspend ${username}?`} description={suspended ? "The player will be able to sign in and play again." : "All active sessions are revoked immediately and the player cannot sign in until reactivated. Credits and progress are kept."} confirmLabel={suspended ? "Reactivate" : "Suspend"} danger={!suspended} loading={busy}>346      {error ? <InlineError>{error}</InlineError> : null}347    </ConfirmDialog>348  );349}350